GitHub OAuth Device Flow(扫码登录)

概述

本模块实现了 GitHub Device Authorization Flow(设备授权流),提供”扫码登录”体验。

与标准 OAuth 重定向流的区别:

特性 标准重定向流 Device Flow(扫码)
用户在 PC 上 点按钮 → 跳 GitHub → 授权 → 跳回 显示二维码 + user_code
用户在手机上 不需要 扫二维码 → 输入 code → 授权
回调方式 GitHub 直接回调 redirect_uri 前端轮询后端 → 后端轮询 GitHub
适用场景 PC 浏览器 PC 大屏展示、无法跳转的环境

完整流程

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
┌─────────────────┐         ┌─────────────────┐         ┌─────────────────┐
│ 前端 (浏览器) │ │ 后端 (8081) │ │ GitHub API │
│ localhost:8082 │ │ │ │ │
└────────┬────────┘ └────────┬─────────┘ └────────┬────────┘
│ │ │
│ ① POST /device/init │ │
├──────────────────────────► │ │
│ │ ② POST device/code
│ │ ───────────────────────► │
│ │ │
│ │ ③ 返回 device_code │
│ │ + user_code │
│ │ + verification_uri │
│ │◄───────────────────────── │
│ │ │
│ ④ 返回 {deviceCode, │ │
│ userCode, │ │
│ verificationUri} │ │
│◄──────────────────────────┤ │
│ │ │
│ ⑤ 渲染二维码(≈ verification_uri) │
│ 显示 user_code: DDB3-23AD │
│ │ │
│ ⑥ 用户用手机扫描二维码 │ │
│ 或直接访问 github.com/login/device
│ 输入 user_code 完成授权 │ │
│ │ │
│ ⑦ GET /device/status │ 每 5s 轮询 │
?deviceCode=xxx │ │
├──────────────────────────► │ │
│ │ ⑧ POST /login/oauth/
│ │ access_token │
│ │ ───────────────────────► │
│ │ ◄── authorization_pending│
│ ⑨ "等待扫码中..." │ │
│◄──────────────────────────┤ │
│ ════════════════════════════════════ │
│ 用户手机在 GitHub 上确认授权 │
│ ════════════════════════════════════ │
│ │ │
│ ⑩ 再次轮询 │ │
├──────────────────────────► │ │
│ │ ⑪ POST /login/oauth/
│ │ access_token │
│ │ ───────────────────────► │
│ │ ⑫ 返回 access_token │
│ │◄───────────────────────── │
│ │ │
│ │ ⑬ GET /user (获取用户信息)│
│ │ ───────────────────────► │
│ │ ◄── 返回 GitHub 用户 │
│ │ │
│ │ ⑭ 查/创建 ShopUser │
│ │ 生成 JWT + refresh token │
│ │ │
│ ⑮ 返回 token + 用户信息 │ │
│◄──────────────────────────┤ │
│ │ │
│ ⑯ 保存 token 到 localStorage │
│ 跳转到 /shop │ │

后端实现

1. OAuthService 接口扩展

文件: src/main/java/com/.../service/OAuthService.java

新增两个接口方法:

1
2
3
4
5
// 初始化设备流:调用 GitHub API 获取 device_code / user_code
Response initDeviceFlow();

// 轮询设备流状态:检查用户是否已授权
Response checkDeviceFlow(String deviceCode);

2. OAuthServiceImpl 实现

文件: src/main/java/com/.../service/impl/OAuthServiceImpl.java

constants

1
2
3
4
5
private static final String GITHUB_DEVICE_CODE_URL = "https://github.com/login/device/code";
private static final String GITHUB_TOKEN_URL = "https://github.com/login/oauth/access_token";
private static final String GITHUB_USER_URL = "https://api.github.com/user";
private static final String GITHUB_EMAIL_URL = "https://api.github.com/user/emails";
private static final String DEVICE_KEY_PREFIX = "weblog:device:";

initDeviceFlow()

1
2
3
4
5
6
7
8
9
10
1. POST https://github.com/login/device/code
Body: client_id, scope=read:user,user:email
→ 返回 device_code, user_code, verification_uri, interval, expires_in

2. 将 device_code 状态存入 Redis:
KEY: weblog:device:{deviceCode}
VALUE: { deviceCode, status: "pending", expiresIn }
TTL: expires_in (GitHub 指定的过期时间)

3. 返回前端: deviceCode, userCode, verificationUri, interval

Request:

1
2
3
4
5
POST https://github.com/login/device/code
Content-Type: application/x-www-form-urlencoded
Accept: application/json

client_id=xxx&scope=read:user,user:email

Response:

1
2
3
4
5
6
7
{
"device_code": "3584d83530jskajsjas46af8289938c8ef79f9dc5",
"user_code": "WDJB-MJJJ",
"verification_uri": "https://github.com/login/device",
"expires_in": 900,
"interval": 5
}

checkDeviceFlow()

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
1. 从 Redis 获取设备流状态
2. 如果 status 已为 "success",直接返回结果(清除 Redis key)
3. 如果 status 为 "expired"/"denied",返回错误(清除 Redis key)
4. 否则 POST https://github.com/login/oauth/access_token
Body: client_id, client_secret, device_code, grant_type=urn:ietf:params:oauth:grant-type:device_code

GitHub 可能返回:
- authorization_pending → 用户未操作,返回 status: pending
- slow_down → 需要放慢轮询,返回 status: pending
- expired_token → 标记为 expired,返回错误
- access_denied → 标记为 denied,返回错误
- access_token → 授权成功!

5. 授权成功后:
a. 用 access_token 获取 GitHub 用户信息 (GET https://api.github.com/user)
b. 用 access_token 获取主邮箱 (GET https://api.github.com/user/emails)
c. 查找 ShopUser (by oauth_provider=github, oauth_id=github_id)
d. 不存在则创建新用户,存在则更新昵称/头像
e. 生成 JWT token + refresh token
f. 返回 token, refreshToken, user 给前端

3. OAuthController 端点

文件: src/main/java/com/.../controller/OAuthController.java

端点 方法 说明
/shop/oauth/device/init POST 初始化设备流,返回二维码数据
/shop/oauth/device/status GET 轮询设备流状态,参数:deviceCode

4. RestTemplate 配置

文件: src/main/java/com/.../config/RestTemplateConfig.java

1
2
3
4
5
6
7
@Bean
public RestTemplate restTemplate() {
SimpleClientHttpRequestFactory factory = new SimpleClientHttpRequestFactory();
factory.setConnectTimeout(10000); // 连接超时 10s
factory.setReadTimeout(30000); // 读取超时 30s
return new RestTemplate(factory);
}

5. 安全配置

WebSecurityConfig.java 中确保 /shop/oauth/** 路径允许匿名访问:

1
.antMatchers("/shop/oauth/**").permitAll()

6. SSL 握手失败重试

由于国内访问 GitHub 偶发 SSL 握手失败(Remote host terminated the handshake),getGitHubAccessToken() 内置重试机制:

1
2
3
int maxRetries = 3;
int retryDelayMs = 1000;
// 指数退避: 1s → 2s → 4s

同时强制走 TLSv1.2 避免某些代理/VPN 对 TLS 1.3 握手兼容性问题:

1
System.setProperty("https.protocols", "TLSv1.2");

前端实现

1. 扫码页面

文件: weblog-vue3/src/pages/frontend/shop-oauth-qrcode.vue

页面结构

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
┌─ 弹窗/居中容器 ──────────────────────┐
│ │
│ GitHub 扫码登录 │
│ 使用 GitHub App 扫描二维码完成登录 │
│ │
│ ┌───────────────┐ │
│ │ │ │
│ │ 二维码图片 │ │
│ │ 220x220 │ │
│ │ │ │
│ └───────────────┘ │
│ │
│ 输入以下代码 │
│ ┌──────────────┐ │
│ │ DDB3-23AD │ │
│ └──────────────┘ │
│ │
│ 或访问 https://github.com/login/ │
│ device 输入以上代码完成授权 │
│ │
│ ⟳ 等待扫码中... │
│ │
[取消] [刷新二维码]
└──────────────────────────────────────┘

关键逻辑

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
// 1. 初始化设备流
async function initDevice() {
loading.value = true
const res = await fetch('/shop/oauth/device/init', { method: 'POST' })
const json = await res.json()
const data = json.data

deviceCode.value = data.deviceCode
userCode.value = data.userCode
verificationUri.value = data.verificationUri

// ★ 先切到二维码视图,让 canvas 出现在 DOM 中
loading.value = false
await nextTick() // 等待 DOM 更新,确保 canvas 已渲染

// 此时 canvas 在 DOM 中了,绘制 QR 码
await QRCode.toCanvas(qrCanvas.value, data.verificationUri, {
width: 220,
margin: 2
})

startPolling() // 开始轮询
}

// 2. 轮询设备状态
async function pollDevice() {
const res = await fetch('/shop/oauth/device/status?deviceCode=' + deviceCode.value)
const json = await res.json()

if (json.data?.token) {
// 登录成功!保存 token 并跳转
setShopTokens(json.data.token, json.data.refreshToken)
router.replace('/shop')
return
}
// status: pending → 继续轮询
scheduleNextPoll()
}

注意事项

  • loading 切换时机:必须先 loading = false 让 canvas 渲染到 DOM,再用 nextTick() 等待,否则 qrCanvas ref 为 null,QR 码画不上去
  • 轮询间隔从 GitHub 返回的 interval 字段获取(通常 5s)
  • 二维码用 qrcode npm 包(v1.5.4)的 QRCode.toCanvas() 渲染到 <canvas> 元素

2. 登录页入口

文件: weblog-vue3/src/pages/frontend/shop-login.vue

在 GitHub 按钮旁边添加扫码入口:

1
2
3
4
5
6
7
<el-button @click="goQrcode">扫码</el-button>

<script>
function goQrcode() {
router.push('/shop/auth/qrcode')
}
</script>

同时增加 OAuth 错误显示逻辑:

1
2
3
4
5
// 检测 URL 中的 oauth_error 参数
if (route.query.oauth_error) {
ElMessage.error('GitHub 登录失败: ' + route.query.oauth_error)
router.replace('/shop/auth') // 清除 URL 参数
}

3. 路由配置

文件: weblog-vue3/src/router/index.js

1
2
3
4
5
{
path: '/shop/auth/qrcode',
component: ShopOAuthQRCode,
meta: { title: '扫码登录' }
}

踩坑记录

1. GitHub OAuth App 须开启 Device Flow

现象: 调用 POST /login/device/code 返回 400

1
{"error":"device_flow_disabled","error_description":"Device Flow must be explicitly enabled for this App"}

解决: 去 GitHub Settings → Developer settings → OAuth Apps → 选择你的 App → 勾选 “Enable Device Flow” → Save

2. RestTemplate 在 4xx 响应时抛异常

现象: GitHub 返回 HTTP 400,但 restTemplate.postForEntity() 在读取响应体之前就抛出 HttpClientErrorException

解决: 在 initDeviceFlow() 的 catch 中先捕获 HttpClientErrorException,从 e.getResponseBodyAsString() 解析错误 JSON 体

3. SSL 握手失败(国内网络问题)

现象: 访问 https://github.com/login/oauth/access_token 时 Java 报错

1
javax.net.ssl.SSLHandshakeException: Remote host terminated the handshake

原因:

  • 国内访问 GitHub 网络不稳定,中间设备可能中断 TLS 连接
  • 某些代理/VPN 设备对 TLS 1.3 握手兼容性不好

解决:

  • 强制使用 TLSv1.2: System.setProperty("https.protocols", "TLSv1.2")
  • 添加重试机制:getGitHubAccessToken() 最多重试 3 次,指数退避

4. 前端 Canvas ref 为 null 导致 QR 码空白

现象: 二维码区域显示了(有边框),但里面没有二维码图案

原因: 代码中 <canvas ref="qrCanvas">v-if="loading" 条件下不存在。API 返回数据后,代码先改了 deviceCode 等变量但没改 loading,执行 nextTick() 时 DOM 里还是加载状态,canvas 没渲染出来,qrCanvas.value = null

解决: 绘制 QR 码前先 loading.value = false 让 DOM 切换到二维码视图,再 await nextTick() 确保 canvas 已就绪。

5. OAuth callback 中 base64 URL 安全编码问题

现象: 前端 atob() 解码用户信息时报错

原因: 后端 Base64.getUrlEncoder() 使用了 URL 安全的 Base64(-_ 替代 +/),前端 atob() 不认识这种变体

解决: 解码前替换字符:

1
2
const standardBase64 = data.user.replace(/-/g, '+').replace(/_/g, '/')
const userStr = atob(standardBase64)

配置项

application.yml

1
2
3
4
5
6
oauth:
github:
client-id: Ov23li0zJcTQdkklkswc
client-secret: xxxxxxxxx
redirect-uri: http://localhost:8081/shop/oauth/callback/github
frontend-url: http://localhost:8082

相关依赖

1
2
3
4
5
6
7
8
<!-- spring-web(内含 RestTemplate) -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>

<!-- Redis(存储设备流状态) -->
<!-- 通过 spring-boot-starter-data-redis 引入 -->

测试指南

手动测试

  1. 确保 Vite 前端运行在 localhost:8082,后端运行在 localhost:8081
  2. 访问 http://localhost:8082/#/shop/auth
  3. 点击”扫码”按钮
  4. 页面显示二维码和 user_code
  5. 用手机扫描二维码,或访问 https://github.com/login/device 输入 user_code
  6. 在手机上确认授权
  7. 浏览器自动跳转到商城首页,登录成功

后端 API 直接验证

1
2
3
4
5
# 1. 初始化设备流
curl -X POST http://localhost:8081/shop/oauth/device/init

# 2. 轮询状态(替换 deviceCode)
curl "http://localhost:8081/shop/oauth/device/status?deviceCode=xxx"

GitHub OAuth Device Flow(扫码登录)
https://neoisconstantine-github-io.pages.dev/2025/06/17/GitHub OAuth Device Flow(扫码登录)/
作者
constantine
发布于
2025年6月17日
许可协议